前面打完,你手上可能有二、三十個測試了。恭喜,同時也先打個預防針:第一次大改版來襲時,你會發現同一個定位散落在十幾個檔案裡。可維護性篇的開場,就從解這個痛開始——Page Object Model,簡稱 POM。
這篇會用 Playwright 官方的 TodoMVC 示範站當素材,從「重構前長怎樣」一路做到「重構後長怎樣」,中間所有程式碼都由 Claude Code 產出。你可以邊讀邊做,全程大約 40 分鐘。
一、痛點:改一顆按鈕,動十四個地方

圖 1:同一個定位散落各處 vs. 集中在頁面物件
假設登入按鈕改版了,定位要換。如果每個測試都自己寫了一份登入操作,你得在十幾個檔案裡找出全部十四處、一一修改,漏一處就紅一片。
為什麼偏偏是登入?因為它是幾乎每個測試都要先做一次的前置動作。它被複製的次數,等於你的測試檔數量。搜尋、加入購物車、送出表單,這些高頻操作都有同樣的問題。
這不是假設。這是所有自動化團隊在第一次大改版時的集體記憶,也是很多自動化專案的死因:改不動,乾脆放著爛。
二、解法:把頁面知識集中到一個地方
圖 2:POM 的兩層分工——測試層問「要驗什麼」,頁面物件層答「怎麼做」
POM 的想法很樸素。每個頁面建一個「頁面物件」:這個頁面有哪些元素、怎麼定位、常見操作(登入、搜尋、加入購物車)怎麼做,全部只寫在這裡。
測試不再自己碰定位,改成呼叫頁面物件提供的操作。兩層各自回答一個問題:
• 頁面物件層回答「怎麼做」:輸入框在哪、要按 Enter 還是點按鈕、送出後要等哪個元素出現。
• 測試層回答「要驗什麼」:走完這段流程之後,畫面應該變成什麼樣子。
畫面改版時,只需要修頁面物件那一處。測試層一行都不用動。
還有個常被低估的附帶好處:測試變得像測試案例了。不寫程式的同事打開測試檔,看到的是「新增待辦(買牛奶)、驗證清單有(買牛奶)」這種句子,看得懂、審得動。測試的可讀性,決定了誰能參與維護。
三、看實際的樣子:TodoMVC 的重構前後
素材用 Playwright 官方示範站 https://demo.playwright.dev/todomvc,不用架環境、不用帳號,打開就能練。
重構前:定位寫在測試裡
// tests/todo-add.spec.ts(重構前)
import { test, expect } from '@playwright/test';
test('新增待辦事項會出現在清單', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
await page.getByPlaceholder('What needs to be done?').fill('買牛奶');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await expect(page.getByTestId('todo-item')).toHaveText(['買牛奶']);
});
// tests/todo-complete.spec.ts(重構前)
import { test, expect } from '@playwright/test';
test('完成待辦事項後會標記為 completed', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
await page.getByPlaceholder('What needs to be done?').fill('買牛奶');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await page.getByTestId('todo-item').getByRole('checkbox').check();
await expect(page.getByTestId('todo-item')).toHaveClass(/completed/);
});
兩個檔案,同一段「開頁面 + 新增一筆待辦」寫了兩次。如果哪天 TodoMVC 把輸入框的提示文字從 What needs to be done? 換成別的,這兩處都要改。三十個測試,就是三十處。
重構後:pages/TodoPage.ts
// pages/TodoPage.ts
import { type Page, type Locator } from '@playwright/test';
export class TodoPage {
readonly page: Page;
readonly newTodoInput: Locator; // 新增待辦的輸入框
readonly todoItems: Locator; // 清單上的每一筆待辦
readonly counter: Locator; // 左下角「N items left」
constructor(page: Page) {
this.page = page;
this.newTodoInput = page.getByPlaceholder('What needs to be done?');
this.todoItems = page.getByTestId('todo-item');
this.counter = page.getByTestId('todo-count');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc');
}
async addTodo(text: string) {
await this.newTodoInput.fill(text);
await this.newTodoInput.press('Enter');
}
async completeTodo(text: string) {
await this.itemWithText(text).getByRole('checkbox').check();
}
async removeTodo(text: string) {
const item = this.itemWithText(text);
await item.hover();
await item.getByRole('button', { name: 'Delete' }).click();
}
async filterBy(name: 'All' | 'Active' | 'Completed') {
await this.page.getByRole('link', { name }).click();
}
// 給測試層做斷言用:回傳 locator,不在這裡下 expect
itemWithText(text: string): Locator {
return this.todoItems.filter({ hasText: text });
}
}
重構後:測試檔只剩流程和斷言
// tests/todo-add.spec.ts(重構後)
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';
test('新增待辦事項會出現在清單', async ({ page }) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addTodo('買牛奶');
await expect(todoPage.todoItems).toHaveText(['買牛奶']);
});
test('完成待辦事項後會標記為 completed', async ({ page }) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addTodo('買牛奶');
await todoPage.completeTodo('買牛奶');
await expect(todoPage.itemWithText('買牛奶')).toHaveClass(/completed/);
});
把測試念出聲看看:開待辦頁、新增(買牛奶)、完成(買牛奶)、驗證它被標記為完成。這段話你可以直接拿去跟業務單位對案例,中間沒有一個字在講 CSS。

圖 3:重構前後的專案結構——多一個 pages/ 資料夾,定位全部搬進去
四、業界寫 POM 的六條慣例
POM 好寫,但也很容易寫歪。下面這六條是各家團隊踩出來的共識,也是你檢查 AI 產出時可以直接拿來對照的清單。
第三條有個常見例外:頁面物件可以提供 isLoaded() 或 expectLoaded() 這種「頁面自我檢查」的方法,用來確認頁面已經進到可操作狀態。這跟商業規則的驗證是兩回事,前者是前置條件,後者才是測試的重點,別混在一起。
五、進階一步:用 fixture 把頁面物件送進測試
上面每個測試都要 new 一次、goto 一次,寫多了還是重複。Playwright 的 fixture 可以把這段前置作業收起來,這也是目前社群最常見的 POM 寫法。
// fixtures.ts
import { test as base } from '@playwright/test';
import { TodoPage } from './pages/TodoPage';
export const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto(); // 每個測試開始前自動開好頁面
await use(todoPage); // 把物件交給測試使用
},
});
export { expect } from '@playwright/test';
// tests/todo-add.spec.ts(改用 fixture)
import { test, expect } from '../fixtures';
test('新增待辦事項會出現在清單', async ({ todoPage }) => {
await todoPage.addTodo('買牛奶');
await expect(todoPage.todoItems).toHaveText(['買牛奶']);
});
測試只剩兩行:做什麼、驗什麼。注意 import 的來源換成了自己的 fixtures,這是唯一要記得的地方。
六、重頭戲:用一句指令重構
這一節的前提是:你手上已經有一批能跑的測試,現在要把它們改成 POM。傳統上這是苦工,也是勸退點;現在它是 Claude Code 最擅長的任務之一。
動手前先確認三件事
• 測試現在是全綠的。有紅燈先修完再重構,否則重構後看到紅燈,你分不出是誰弄壞的。
• 已經 git commit 過,有一個可以退回去的點。AI 一次會動很多檔案,退路要先留好。
• 測試不會飄。同樣的程式碼連跑十次都要是同樣結果,這是下一節「前後對照」能成立的基礎。
如果你的測試本來就會飄(同樣的程式碼,跑十次有一兩次紅),先把飄的修掉再重構。否則重構後出現紅燈,你分不出是重構弄壞的,還是它本來就會這樣。這件事沒有捷徑,跳過它,後面那道閘門就形同虛設。
重構指令
請 Claude Code 這樣做:
請把 tests/ 底下所有測試重構成 Page Object Model:
1) 為出現過的每個頁面建立頁面物件,放在 pages/ 資料夾,檔名用 XxxPage.ts。
2) 定位一律宣告在建構子的 readonly locator;方法用業務語言命名,例如 addTodo、completeTodo,不要用 clickButton 這種名字。
3) 把定位和常用操作移進頁面物件,測試檔只留業務流程和斷言。斷言不要搬進頁面物件。
4) 重構過程不可改變任何測試的行為——重構前先全部跑一遍記錄結果,重構後再跑一遍,兩次結果必須一致。
5) 完成後給我摘要:建了哪些頁面物件、每個測試檔改了什麼,以及重構前後的測試結果對照。
第 4 點是這段指令的靈魂,值得單獨講。
七、重構的鐵律:行為不變,測試為證

圖 4:重構前後的執行結果比對,是判斷「重構成立」的唯一憑據
重構的定義是「改善結構、不改變行為」。怎麼確認行為沒變?跑測試。重構前全綠,重構後也要全綠,而且是同一批測試在綠。
這個前後對照的紀律,交給 AI 執行時更加重要。它動的檔案又多又快,沒有這道閘門,你分不出「重構」和「順手改壞」。這是用測試守護測試自己,而你,是要求出示證據的人。
實際操作上,把結果存成檔案比用眼睛記可靠:
# 重構前
npx playwright test --reporter=list > before.txt
# ...請 Claude Code 重構...
# 重構後
npx playwright test --reporter=list > after.txt
diff before.txt after.txt # 除了執行時間,不該有其他差異
八、怎麼審查 AI 的重構結果
不用逐行讀程式。抽查下面五件事,就有八成把握。
如果 AI 在重構過程中「順手」把某個斷言改寬鬆了(例如 toHaveText 改成 toContainText),那不是重構,是把測試改得比較容易過。這種變更要單獨拿出來討論,不能混在重構裡。
九、什麼時候不需要 POM

圖 5:POM 的價值,跟測試數量與畫面變動頻率成正比
誠實說:測試少於十個、或都是一次性的探索腳本,POM 是過度設計。它的價值跟測試數量與變動頻率成正比。
導入時機的訊號很具體:當你第二次因為同一個畫面改動而修改多個測試檔,就是時候了。也可以從中間做法起步——先把最常重複的那段(通常是登入)抽成一個物件,其他維持原狀,等重複再出現時再抽下一個。
十、今天的練習
重構要有東西可以重構,所以先確認你站在哪個起點:
• 手上已經有 TodoMVC 的測試(實戰篇一路做下來就會有)——跳過練習 1,直接從練習 2 開始。
• 手上沒有,或想用一份乾淨的素材練——先做練習 1 產出素材。
三個練習加起來約 40 分鐘(含練習 1)。第四個回到你們自己的系統。
練習 1(沒有現成測試才做):先做出「重構前」的素材(約 15 分鐘)
開一個空資料夾,讓 Claude Code 幫你建立三個刻意不用 POM 的測試。
請 Claude Code 這樣做:
請在這個資料夾建立 Playwright 專案,並寫三個測試檔,測試對象是 https://demo.playwright.dev/todomvc:
1) todo-add.spec.ts:新增一筆待辦,驗證它出現在清單。
2) todo-complete.spec.ts:新增後標記完成,驗證它被標記為 completed。
3) todo-filter.spec.ts:新增兩筆、完成其中一筆,分別切到 Active 和 Completed 頁籤,驗證清單內容。
這一版故意不要用 Page Object Model,定位直接寫在每個測試裡。寫完幫我跑一次,把執行結果貼給我。
跑完先看一眼三個檔案:那段「開頁面、新增一筆待辦」是不是重複了三次?這就是待會要處理的東西。
練習 2:請 Claude Code 重構(約 10 分鐘)
先做第六節那三個確認:測試全綠、已經 commit、不會飄。三項都過了,再用第六節那段指令,把 tests/ 重構成一個 TodoPage 物件。記得要求重構前後的結果對照,不要省略。
如果你想多練一步,重構完之後再下一句:「請把 TodoPage 改成用 fixture 注入,測試檔不要再自己 new。」對照一下前後兩版,你會很有感覺。
練習 3:驗收(約 10 分鐘)
拿第八節的五個抽查點逐項對過去。特別做這兩件事:
• 全專案搜尋 getByPlaceholder,確認它只出現在 pages/ 底下。
• 打開一個測試檔,念給自己聽,是不是變成了「新增待辦(買牛奶)、驗證清單有(買牛奶)」這種業務語言。
有任何一項不過關,把問題貼回去請 Claude Code 修,不要自己默默改。你要練的是「能講清楚哪裡不對」,這個能力比會改程式重要。
練習 4:回你們自己的系統
同一招套用在自己的測試套件上。有一個細節請堅持:頁面物件的操作命名,用你們團隊的行話——進件、核價、派工、結案——不要用 submitForm 這種泛用詞。測試讀起來越像你們平常在會議室講的話,能參與審查的人就越多。
重構完之後,把 pages/ 這個資料夾放進 code review 的必看清單。往後幾年,它會是整個測試專案裡最常被改動、也最值得被看的地方。